상태관리 : 나의 지역/전역 상태관리 가치관
상태 관리(State Management) 상태 관리는 단순히 데이터를 저장하는 행위가 아니라, 데이터의 흐름과 변경을 예측 가능하게 제어하는 과정이다. 즉, 사용자 인터랙션 → 상태 변화 → UI 반영의 전체 사이클을 다루는 것이다.
이 로직은 곧 어플리케이션의 예측 가능성과 유지보수성, 확장성을 좌우하기 때문에 단순한 코드 패턴을 넘어 하나의 아키텍처적 개념으로 다뤄야한다.
지역 상태 관리
지역상태 관리를 잘 하기 위한 React 도큐먼트의 내용을 살펴보면 다음과 같다.
그리고 이 상태관리 방식을 따르고 있다.
- 불변성 유지 (Immutable Update)
- React는 얕은 비교만하기 때문에 불변성이 중요하다.
- 객체 state는 직접 수정하지 않고 새로운 복사본으로 갱신하자. (immer 고려)
- React는 얕은 비교만하기 때문에 불변성이 중요하다.
- 두개 이상의 변수를 항상 동시에 업데이트 한다면 단일 State로 병합하는 것을 고려하자
- 상태가 많을 수록 여러 state에서 무엇을 사용해야 할지 판단하기 어려워지기 때문이다.
- 예를 들어 x,y 좌표를 저장하는 state는 하나로 만들자
- State의 모순 피하기
- 예를 들어, isSent와 isSending은 로직상 동시에 true가 되면 안된다.
-> 컴포넌트가 복잡해지면 무슨 일이 일어났는지 이해하기가 어렵다. 'sending'|'sent'상태를 가진 status 형식의 state로 바꾸고, 필요하다면isSending = status === 'sending'형태의 상수를 만들자
- 예를 들어, isSent와 isSending은 로직상 동시에 true가 되면 안된다.
- Derived State를 피하라
- 계산 가능한 값은 state가 아니라 변수로 관리하자.
- Props를 State에 미러링하지 말자
- 초기값은 미러링되어도, 부모에서 props를 변경했을 때는 미러링이 안된다. 결국 effect가 필요해진다.
- State의 중복을 피하자 ( Single Source Of Truth )
- 정보의 원천이 여러 곳에 있으면 사이드이펙트 발생확률이 올라가기 때문이다.
- 예를 들어, 배열을 state에 선언하고, selected 라는 state를 선언해서 배열 아이템에 넣는 행위를 하지말자
- selected item의 프로퍼티를 수정한다고 했을 때, selected state를 변경되지만 items state는 또 따로 수정해야되기 때문이다.
- selectedId라는 것을 state로 두고, items에서 직접 수정하게 만들자
- nested 객체 state를 피하자
- 결국 불변성이 필요한 react에서는 하위의 프로퍼티를 수정한다는 것은 변경된 부분부터 상위까지 모든 객체의 복사본을 만들어야 하는 어려움이 존재하기 때문이다.
- state를 최대한 평탄하게 만드는 것을 고민하자
- 메모리 관리를 위해서, 또 불변성을 쉽게 만들기 위해서 immer 사용을 고려해보자
정리해보면, 일반적인 State를 관리함에 있어 중요한 것은 하나의 State는 하나의 컴포넌트만 소유(책임)해야 한다는 것과 상태의 영향 범위를 고려해 적절한 컴포넌트에 배치하는 것 이라고 생각한다.
전역 상태 관리 도구 없이 Props Drilling 해결하기
왜 사람들은 Local State 와 Global State를 나누게 되었을까 : Props Drilling 문제
다수의 자식 컴포넌트에게 영향을 미치는 지역상태는 자식의 자식의 자식까지 영향을 미치기 위해
Props를 상단부터 하단 까지 내리는 경우가 있는데, 이를 Props Drilling이라고 말한다.
Props는 그 정보만으로도 데이터의 사용처를 알게하는 것이 유지보수에 좋은데,
Props Drilling은 아래와 같은 문제를 야기한다.
- 중간 단계 컴포넌트들이, 관심사와 맞지 않는 데이터를 props로 주입받아야함
- 상태 변경 시, 중간 단계 컴포넌트들이 불필요하게 리렌더링됨
- 데이터 흐름 추적이 어려워짐 (의도 파악이 어려움)
전역 상태를 사용하지 않고도 Props Drilling을 해결하기
우리는 Props Drilling을 피하기 위해, 쉬운 방법인 전역 상태관리 도구를 찾게된다.
하지만, 전역 상태는 사용이 쉽지만, 그만큼 의존성과 복잡도, 사이드 이펙트의 위험도 함께 증가한다 대부분의 경우 상태는 컴포넌트 가까이서 로컬로 관리되는 것이 바람직하며 Prop Drilling과 같은 문제도 아래 방법들로 충분히 해결 가능하다
-
props getter/setter 패턴 + useReducer- 하위 컴포넌트에 props로 state를 직접 전달하는 것이 아니라. state의 getter와 setter를 전달하는 방식을 사용하면, drilling은 일어나지만, state의 책임을 갖는 컴포넌트를 찾기 쉬워진다.
- 또한, getter와 setter를 잘 메모이제이션하면 불필요한 리렌더링도 막을 수 있다.
- useReducer를 이용하면 다양한 setter와 getter를 만드는 것이 편리해진다.
const reducer = (state, action) => { switch (action.type) { case 'PLUS': return state + 1; case 'MINUS': return state - 1; default: return state; } }; const Component = () => { const [state, dispatch] = useReducer(reducer, initialState); const getter = () => state; const plus = () => dispatch({ type: 'PLUS' }); const minus = () => dispatch({ type: 'MINUS' }); return <Child getter={getter} plus={plus} minus={minus} />; };
-
합성패턴이나 RenderProps 패턴을 이용해서, 상위에서 바로 Props 넣어주기하위에서 직접 자식들을 import하여 렌더링하는 것이 아니라, children을 받는 컴포넌트로 만들어 바로 state의 책임이 있는 컴포넌트에서 사용하는 컴포넌트로 바로 props를 넣어주면 된다. 물론 컴포넌트의 설계가 매우 중요하지만, children을 받게 설계한다면 메모이제이션만 잘 되어 있다면, 관계없는 중간 컴포넌트의 리렌더링을 막을 수 있다.
const Top = () => { const [state, setState] = useState(0); return ( <관계없는컴포넌트> <관계있는컴포넌트 data={state} /> </관계없는컴포넌트> ); };또 이런 패턴은 children을 참조값으로 들고 있기 때문에
<관계없는컴포넌트/>내부의 state가 변경되었을 때,<관계있는컴포넌트/>는 리렌더링 되지않는다. 즉 리렌더링을 격리할 수 있다.
-
Context API를 사용한다.
Context API는 상태관리 도구가 아니다. 의존성 주입 도구다.
보통 "상태관리" 라고 함은, 변화하는 데이터들을 관리하는 것인데, 상태의 초기 값을 저장하거나, 현재 상태의 값을 읽거나, 새로운 데이터로 상태를 업데이트 하는 등의 행위를 뜻한다.
Context는 상태를 “관리”하지 않는다. 상태를 “공유” 할 뿐이다. 즉, Context API는 의존성 주입 도구이지 상태관리 도구가 아니다. 또, Context는 상태의 “수명주기”를 관리하지 않는다. 상태의 생성과 업데이트는 여전히 useState/useReducer의 책임이다.
단순히 이미 존재하는 상태를 다른 컴포넌트들과 쉽게 공유할 수 있게 해주는 역할을 할 뿐이다.
전역 상태 관리 도구를 사용한다면
전역 상태는 사용이 쉽지만, 그만큼 의존성과 복잡도, 사이드 이펙트의 위험도 함께 증가한다. 대부분의 경우 상태는 컴포넌트 가까이서 로컬로 관리되는 것이 바람직하다.
다만 페이지 전반에 걸쳐 사용되는 컴포넌트(예: 팝업, 컨텍스트 메뉴)나, 서비스 전체에 영향을 주는 상태(예: 언어, 다크 모드, 장바구니)는 전역 상태로 관리하는 것이 효율적이다.
상태 설계는 개발자의 기준과 선택에 따라 달라지지만, 한 번 결정된 상태 구조는 애플리케이션의 라이프사이클 전반에 영향을 미치므로 신중한 설계가 필요하다.
Redux
MVC의 양방향 바인딩 구조에서 누가 상태를 바꿨는지 알기 어려웠던 문제를 개선하기 위해
Action → Dispatcher → Store → View(React Component)의 Flux 패턴을 도입했고
단방향 데이터 흐름을 통해 “어디서 변경이 시작돼서 어디로 전파되는지”를 한눈에 추적하기 쉽다.
Redux는 단순히 상태를 저장하는 수준이 아니라, 시간을 기준으로 상태 변화를 추적하고, 상태를 스냅샷으로 돌릴 수 있다. 또한 그 시간을 기준으로 디버깅까지 용이하다는 장점이 있다.
또, Next가 널리 쓰이는 시대에서,
상태를 Server에서 미리 채우고, HTML에 담아 Client로 보내고, 앱을 시작할 때 불러오는 것에 뛰어나다.
<script>
window.__PRELOADED_STATE__ = { user: { name: "병건" } };
</script>
// 클라이언트에서
const store = createStore(reducer, window.__PRELOADED_STATE__);
Recoil
Recoil은 상태 간 관계에 집중해서 각 상태를 그래프 형태로 모델링하는 전역 상태관리 도구다.
atom은 상태의 최소 단위, selector는 파생 상태를 표현한다.
Recoil이 어떤 상태가 다시 계산되어야 하는지를 자동으로 추적한다.
의존 관계를 명시한 상태에서 Atom이 변경되면, 관련 Selector들 상태가 변경된다.
하지만, 관리되고 있지 않다. 19년도 이후부터 릴리즈가 없다. 곧 도태될 것 같다.
Zustand
Recoil과 같이 전역 상태를 사용하기 위한 상태관리 라이브러리이다.
Redux의 Flux패턴을 계승했기에 Redux의 DevTool을 그대로 사용할 수 있다. 하지만 Redux와 달리 보일러 플레이트 없이, 간단하게 store를 만들고 필요한 컴포넌트에서 간단히 구독하는 방식을 제공하며 Selector가 내장되어 있어, 컴포넌트 렌더링 최적화에도 편하다.
특징은 Zustand는 React 외부에서 상태를 관리하고, React에서 직관적으로 접근할 수 있도록 만들어져 있다.
create를 통해 React 외부에 스토어를 정의할 수 있으며,
내부적으로 useSyncExternalStore를 활용해 React 18 이상에서도 정확한 리렌더링 동기화를 보장한다.
Hydartion Error가 두드러지는 Zustand
Hydration Error는 서버에서 렌더링된 HTML과 클라이언트 초기 렌더링 결과가 불일치할 때 발생해 Zustand의 특징은, React외부에서 동작한다는 것인데, 이것 때문에 Hydartion Error가 두드러진다.
persist를 사용하면, 서버에서는 initialState 그대로 내려온다. 클라이언트에서 Mount되기 전에, Zustand가 스토리지를 통해 상태를 미리 복원해버리면 어김없이 하이드레이션 에러가 발생한다. React보다 먼저 복원이 일어나기 때문이다.
그래서 persist된 상태는 useEffect 안에서 불러오기. 즉, mount 후 복원등의 방법으로 해결할 수있다.
const useStore = create(persist((set) => ({ count: 0 }), { name: 'counter' }));
const Component = () => {
const count = useStore((s) => s.count);
useEffect(() => {
useStore.persist.rehydrate(); // mount 이후 복원
}, []);
};
그렇다면 뭘 사용할까요 (개인적인 의견)
- 지역상태를 보통 사용한다.
- 상태 합칠 수 있는지 확인한다.
- 미러링 하지 않도록 한다. 단일 진실의 원천
- 파생상태를 만들지 않고 계산으로 해결한다.
- 모순된 상태를 만들지 않는다. Status로 해결한다.
- Props 드릴링을 해결할 때 Context API를 먼저 고려한다.
- 의존성 주입이 위주라면, 혹은 컴포넌트 라이프사이클에 따라 초기화되거나 그럴때는 Context API 사용
- 아니면 전역상태관리도구 사용 고민한다.
- 전역상태관리 라이브러리는 보통 Zustand 사용한다.
- Redux 안쓰는 이유
- 일단 작성해야되는 코드가 너무 많다.
- Thunk같은 걸로 서버스테이트도 통합할 수 있지만, 이러면 상태관리 로직에 집중이 안된다. 요즘은 서버스테이트는 분리해서 사용하니까, 뭐 스냅샷을 복원하는 경우를 잘 사용안해서 장점 모르겠음
- Recoil + Jotai 안쓰는 이유
- 그래프를 이용해서 상태를 표현하는 애들인 만큼, 파생상태에 집중된 컨셉이라고 생각한다.
- 파생상태는 컴포넌트에서 직접 사용하면 되지 않나 싶어서 장점을 잘 모르겠다.
- 그리고 Recoil은 요즘 릴리즈도 안나오고해서, Recoil을 미리 사용하던 것이 아니라면 안쓸듯
- Jotai도 Atom기반이기 때문에 Recoil과 같을 것이라고 생각한다.
- Zustand 쓰는 이유
- 물론 하이드레이션 에러가 두드러지지만, Persist를 거의 안써서 아직 못만나봣따.
- DevTool은 잘 안쓰지만, 있다는 것이 장점
- React 외부에 있기 때문에, React 외부에서 Store 접근이 가능하다는 점
- Recoil만큼 사용이 간단하다는 점
- Redux 안쓰는 이유
References
- https://www.stevy.dev/react-state-management-guide/
- https://mingule.tistory.com/74
- https://frontendmastery.com/posts/the-new-wave-of-react-state-management/
- https://medium.com/@bgmin2e/nextjs
- https://dohyeon.dev/zustand-persist-hydration-error-troubleshooting
- https://zustand.docs.pmnd.rs/guides/nextjs